在鐵人賽的開篇,我們介紹了應用程式的開發過程,涵蓋了 Web Application 和 Command Line Tool 的開發。隨後,我們討論了如何透過工具提升程式碼的可讀性與維護性。
接下來的幾個章節將聚焦於另一個提高應用程式品質的關鍵要素——測試。當開發者完成新功能後,通常會啟動應用程式,然後透過瀏覽器或終端進行測試,這種方式被稱為手動測試(Manual Testing)。然而,手動測試有一個顯著的缺點:每次執行測試都需要投入一定的時間和精力。因此,開發者通常只會針對本次新增的功能進行測試。隨著應用程式規模的擴大,新功能或更改可能會影響到現有功能的正常運行,而手動測試無法全面確保新更改不會破壞舊有的功能。
相對於手動測試,自動化測試則是另一個有效的方法。由於自動化測試能夠快速重複執行大量測試,開發者可以利用它來驗證舊有功能,確保新更改不會破壞現有功能,從而有效解決之前提到的問題。雖然建立自動化測試在初期需要投入時間和資源,但從長期來看,這將顯著提高測試效率並確保軟體產品的整體品質。
之前針對應用程式所撰寫的稱為 Product Code,這些 Product Code 共同構成了一個完整的軟體產品。而自動化測試的構建同樣需要涉及程式碼,開發者必須撰寫額外的程式碼,這些程式碼被稱為 Test Code。Product Code 與 Test Code 就如同行走時的雙腳,缺一不可,缺少任何一隻腳都會導致行走不穩。Product Code 的目標是確保業務邏輯符合產品需求,而 Test Code 則用來檢測產品代碼是否滿足這些需求。
Test Code 的結構通常劃分為三個區域,俗稱 3A。接下來,我將結合範例程式碼分別介紹這三個 A。
Arrange
在這一步,開發者需要準備測試環境和所需資料,這通常包括初始化物件、設置變數等。安排的目的是為測試提供必要的上下文,確保其能正確運行。
在範例中,開發者準備了變數 a
與 b
,其值分別為 5 與 6。
Act
此步驟涉及執行測試的核心動作,即呼叫需要測試的函式。
在範例中,開發者呼叫了想要測試的 sum
函式,並取得其返回值。
Assert
最後,開發者需要檢查測試結果是否符合預期。這通常涉及使用 Assert
來驗證函式的返回值或系統狀態是否與預期一致。
在範例中,開發者驗證返回值是否等於預期的 11。如果符合預期,則代表 sum
這個函式有符合軟體需求。
def sum(a: int, b: int) -> int:
return a + b
def test_sum():
# Arrange
a = 5
b = 6
# Act
result = sum(a, b)
# Assert
assert result == 11
在大多數測試情境中,Arrange
通常是最繁瑣的環節,因此許多 Python 測試套件都特別注重 Arrange
的部分,旨在幫助開發者更輕鬆地撰寫這一階段的代碼。
本篇文章將介紹的測試套件是 Pytest。Pytest 針對 Arrange
步驟提供了一個名為 Fixture
的特性,使開發者能夠輕鬆設置和共享測試前的 Arrange
步驟。接下來,我將通過範例來介紹 Pytest Fixture。
本次範例使用的是 Pytest 8.3.3 版本
poetry add pytest==8.3.3
以購物平台的商品為例,首先,我們需要建立一個 product
目錄,然後在該目錄內創建 model.py
文件。在這個文件中,可以直接使用之前 Pydantic 章節中介紹的 Product
類別。
from pydantic import BaseModel, PositiveInt
class Product(BaseModel):
name: str
price: PositiveInt
接著,建立一個 handler.py
文件,其中包含兩個函式。第一個函式根據傳入的金額參數,篩選出所有價格高於該金額的商品名稱。第二個函式則負責找出價格最貴的商品名稱。
from .model import Product
def get_expensive_product_names(
products: list[Product], price_threshold: int
) -> list[str]:
return [product.name for product in products if product.price > price_threshold]
def get_most_expensive_product_name(products: list[Product]) -> str:
return max(products, key=lambda product: product.price).name
最後,我們回到專案目錄下的 tests 目錄,該目錄在使用 poetry new
指令時已經自動生成。接著,我們在 tests
目錄下建立 test_product.py
文件。這段 Test Code 可以分為三個區塊。
首先,第一個區塊使用了 Pytest 的 Fixture
,我們可以在 Fixture
中進行 Arrange
,即宣告測試所需的變數。通常,Fixture
的函式名稱會代表 Arrange
的變數名稱。比如,這次我們宣告了一組產品,因此 Fixture
可以命名為 products
。
接下來,第二個區塊是測試 expensive_product_names
函式。Pytest 預設會自動執行所有以 test
開頭的測試案例,因此測試案例的名稱通常是 test_
加上想要測試的函式名稱。
此外,當測試案例的參數名稱與 Fixture
名稱相同時,Pytest 會在執行測試時自動執行對應的 Fixture
,並將結果傳入測試案例中。以第一個測試情境為例,因為測試案例的參數名稱是 products
,Pytest 會先執行 products Fixture
,成功初始化一系列商品,然後將這些商品傳入測試案例中。因此,在這個情境下,我們不需要重新宣告一組產品變數。
同理,第二個測試情境也是如此,直接使用 Fixture
來獲得一組商品。這樣的設計讓我們只需建置一次 Fixture
,將通用性高的變數封裝在其中,就可以在多個測試情境中重複使用,這不僅節省了開發成本,還提升了測試情境的可讀性。
import pytest
from product.handler import get_expensive_product_names, get_most_expensive_product_name
from product.model import Product
# Arrange
@pytest.fixture
def products() -> list[Product]:
return [
Product(name="Apple", price=30),
Product(name="Orange", price=25),
Product(name="Phone", price=30000),
Product(name="TV", price=48888),
Product(name="Toilet Paper", price=299),
Product(name="Earphone", price=6000),
]
def test_get_expensive_product_names(products):
# Arrange
price_threshold = 100
# Act
product_names = get_expensive_product_names(products, price_threshold)
# Assert
assert product_names == ["Phone", "TV", "Toilet Paper", "Earphone"]
def test_get_most_expensive_product_name(products):
# Act
product_name = get_most_expensive_product_name(products)
# Assert
assert product_name == "TV"
我們只需要執行 poetry run pytest
即可看到測試的結果。